跳到主要内容
Spring Boot RESTful API 分层开发与测试实战 | 极客日志
Java java
Spring Boot RESTful API 分层开发与测试实战 基于 Spring Boot 构建商品管理 RESTful API,涵盖分层代码(Controller/Service/Repository/Entity)、三种测试策略(单元、集成、MockMvc)以及 Spring Security 的 Basic Auth 与 JWT 认证实现,并给出了完整可运行的示例。
Spring Boot RESTful API 分层开发与测试实战
RESTful API 是 Java 后端最常见的交互方式。在 Spring Boot 里搭一套像样的接口,代码分层、测试设计和安全控制缺一不可。这篇东西会用一个商品管理的例子,把开发、测试到认证整个流程走一遍,中间会穿插一些我个人觉得省事或踩坑的判断。
RESTful 设计:把资源当主角
用 URI 表示资源,HTTP 动词表示动作,状态码反馈结果——这是 REST 的基本约定。GET 拿资源,POST 新建,PUT 整体更新,PATCH 局部更新,DELETE 删除。状态码也得用对:200 成功,201 创建成功,400 参数不对,401/403 鉴权问题,500 服务端炸了。这些规范能让接口更好看懂,也方便后期维护。
代码实战:从实体到控制器
标准的分层结构:Entity → Repository → Service → Controller。这里用 H2 内存库,方便演示。
依赖配置
pom.xml 引入 Web、JPA、H2 和测试依赖:
<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-jpa</artifactId >
</dependency >
<dependency >
<groupId > com.h2database</groupId >
<artifactId > h2</artifactId >
< > runtime
org.springframework.boot
spring-boot-starter-test
test
scope
</scope >
</dependency >
<dependency >
<groupId >
</groupId >
<artifactId >
</artifactId >
<scope >
</scope >
</dependency >
</dependencies >
实体:Product 一个普通的 JPA 实体,加上了 Lombok 风格的 Getter/Setter(这里没贴全,但手写也没问题)。
import javax.persistence.*;
@Entity
@Table(name = "product")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String productId;
private String productName;
private double price;
private int sales;
public Product () {}
public Product (String productId, String productName, double price, int sales) {
this .productId = productId;
this .productName = productName;
this .price = price;
this .sales = sales;
}
public Long getId () { return id; }
public void setId (Long id) { this .id = id; }
public String getProductId () { return productId; }
public void setProductId (String productId) { this .productId = productId; }
public String getProductName () { return productName; }
public void setProductName (String productName) { this .productName = productName; }
public double getPrice () { return price; }
public void setPrice (double price) { this .price = price; }
public int getSales () { return sales; }
public void setSales (int sales) { this .sales = sales; }
}
Repository:省掉 SQL Spring Data JPA 让我们不用写常规的 CRUD 代码。这里加一个自定义查询,找出销量大于某个值的商品。
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface ProductRepository extends JpaRepository <Product, Long> {
List<Product> findBySalesGreaterThan (int sales) ;
}
Service:业务所在 Service 层负责事务,Controller 只做参数转发。下面的 getTopSellingProducts 演示了简单的内存排序逻辑,实际大数据场景下肯定要用数据库排序。
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
public class ProductService {
@Autowired
private ProductRepository productRepository;
@Transactional
public void addProduct (Product product) {
productRepository.save(product);
}
@Transactional
public void updateProduct (Product product) {
productRepository.save(product);
}
@Transactional
public void deleteProduct (Long id) {
productRepository.deleteById(id);
}
@Transactional(readOnly = true)
public List<Product> getAllProducts () {
return productRepository.findAll();
}
@Transactional(readOnly = true)
public List<Product> getTopSellingProducts (int topN) {
List<Product> products = productRepository.findBySalesGreaterThan(0 );
products.sort((p1, p2) -> p2.getSales() - p1.getSales());
if (products.size() > topN) {
return products.subList(0 , topN);
}
return products;
}
}
Controller:暴露端点 @RestController 配合 @RequestMapping,定义增删改查接口。这里把路径映射写死为 /api/products,REST 风格可以再优化,但够用了。
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/products")
public class ProductController {
@Autowired
private ProductService productService;
@GetMapping("/")
public ResponseEntity<List<Product>> getAllProducts () {
List<Product> products = productService.getAllProducts();
return new ResponseEntity <>(products, HttpStatus.OK);
}
@PostMapping("/")
public ResponseEntity<Void> addProduct (@RequestBody Product product) {
productService.addProduct(product);
return new ResponseEntity <>(HttpStatus.CREATED);
}
@PutMapping("/{id}")
public ResponseEntity<Void> updateProduct (@PathVariable Long id, @RequestBody Product product) {
product.setId(id);
productService.updateProduct(product);
return new ResponseEntity <>(HttpStatus.OK);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteProduct (@PathVariable Long id) {
productService.deleteProduct(id);
return new ResponseEntity <>(HttpStatus.NO_CONTENT);
}
@GetMapping("/top-selling")
public ResponseEntity<List<Product>> getTopSellingProducts (@RequestParam int topN) {
List<Product> products = productService.getTopSellingProducts(topN);
return new ResponseEntity <>(products, HttpStatus.OK);
}
}
测试:单元、集成和 Mock 三层 没测试的代码上线心里没底。Spring Boot 提供了三种主要测试手段,各有各的用场。
单元测试:单纯测 Service 直接启动 @SpringBootTest,操作数据库然后断言,这是最省事的单元测试写法,但如果数据有初始脚本会依赖顺序。
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
class ProductServiceTests {
@Autowired
private ProductService productService;
@Test
void testAddProduct () {
Product product = new Product ("P006" , "平板" , 2000.0 , 70 );
productService.addProduct(product);
List<Product> products = productService.getAllProducts();
assertThat(products).hasSize(6 );
}
}
集成测试:加 Web 环境 用 TestRestTemplate 发起真正的 HTTP 请求,验证整个链路。启动会慢一些,但更能暴露序列化、路径匹配的问题。
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.web.server.LocalServerPort;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ProductControllerTests {
@LocalServerPort
private int port;
@Autowired
private TestRestTemplate restTemplate;
@Test
void testGetAllProducts () {
List<Product> products = restTemplate.getForObject(
"http://localhost:" + port + "/api/products/" , List.class);
assertThat(products).hasSize(5 );
}
}
Mock 测试:最快的 Controller 验证 个人觉得 MockMvc 最顺手:不用启动真正的 Servlet 容器,把 Service 替换成 Mock,既能测路径映射又能断言响应格式,速度飞快。不过要注意,它不会走过滤器和拦截器,所以安全相关的测试还是得靠集成测试。
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import java.util.Arrays;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(ProductController.class)
class ProductControllerMockTests {
@Autowired
private MockMvc mockMvc;
@MockBean
private ProductService productService;
@Test
void testGetAllProducts () throws Exception {
when (productService.getAllProducts()).thenReturn(Arrays.asList());
mockMvc.perform(get("/api/products/" ))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON));
verify(productService, times(1 )).getAllProducts();
}
}
安全控制:Basic Auth 和 JWT 生产环境不加权限等于裸奔。Spring Security 是标配,常见两种用法:简单内部系统用 Basic Auth;前后端分离用 JWT。
Basic Auth 快速接入 配置一个 WebSecurityConfigurerAdapter,规定哪些路径需要什么角色。这里 /api/products/top-selling 要求 ADMIN,其他 products 接口要求 USER。
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure (HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/api/products/top-selling" ).hasRole("ADMIN" )
.antMatchers("/api/products/**" ).hasRole("USER" )
.and().httpBasic();
}
}
实际项目里密码建议用 BCrypt 加密,上面为了方便用了 NoOpPasswordEncoder,线上别这么干。
JWT 无状态认证 前后端分离时,JWT 更合适。它把用户信息加密进 Token,服务端不存 Session,扩展性好。下面是一个简化版的工具类,生成和校验 Token。
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
@Component
public class JwtUtil {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expiration}")
private Long expiration;
public String generateToken (String username) {
Map<String, Object> claims = new HashMap <>();
return createToken(claims, username);
}
private String createToken (Map<String, Object> claims, String subject) {
return Jwts.builder()
.setClaims(claims)
.setSubject(subject)
.setIssuedAt(new Date (System.currentTimeMillis()))
.setExpiration(new Date (System.currentTimeMillis() + expiration * 1000 ))
.signWith(SignatureAlgorithm.HS256, secret)
.compact();
}
public Boolean validateToken (String token, String username) {
final String extractedUsername = extractUsername(token);
return (extractedUsername.equals(username) && !isTokenExpired(token));
}
private String extractUsername (String token) {
return extractClaim(token, Claims::getSubject);
}
private Boolean isTokenExpired (String token) {
return extractExpiration(token).before(new Date ());
}
private Claims extractAllClaims (String token) {
return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
}
private <T> T extractClaim (String token, Function<Claims, T> claimsResolver) {
final Claims claims = extractAllClaims(token);
return claimsResolver.apply(claims);
}
private Date extractExpiration (String token) {
return extractClaim(token, Claims::getExpiration);
}
}
通常还要配一个 JwtRequestFilter 来拦截请求、解析 Token、设置安全上下文。这里省略了,但核心思路就是这些。配置文件别忘了填上密钥和过期时间:
# application.properties
jwt.secret=mysecret
jwt.expiration=3600
密钥别硬编码在配置里,生产环境用环境变量或 vault 注入。
收尾 这篇文章用商品管理这个简单场景,演示了 Spring Boot RESTful API 的分层搭建、测试策略和安全加固。三层测试各有侧重,平时写单测和 Mock 就够了,核心流程再用集成测试兜底。安全方面,小项目 Basic Auth 够用,前后端分离就上 JWT。根据实际情况组合这些技术点,后面慢慢扩展就行。
相关免费在线工具 Keycode 信息 查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online
Escape 与 Native 编解码 JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online
JavaScript / HTML 格式化 使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online
JavaScript 压缩与混淆 Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online
Base64 字符串编码/解码 将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
Base64 文件转换器 将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online